fix: accept IDs as well as names for --channel, --environment and --tenant - #702
NickJosevski wants to merge 5 commits into
Conversation
NickJosevski
left a comment
There was a problem hiding this comment.
Reviewed for correctness regressions vs the old pass-through behavior. Three findings, inline below. The precedence flip and extra round trips are already covered in the PR description, so no comments on those.
|
|
||
| // FindTenant looks a tenant up by either its ID or its name. | ||
| func FindTenant(octopus *octopusApiClient.Client, tenantIdentifier string) (*tenants.Tenant, error) { | ||
| tenant, err := octopus.Tenants.GetByIdentifier(tenantIdentifier) |
There was a problem hiding this comment.
The name fallback inside Tenants.GetByIdentifier (GetByName in the SDK) issues a single tenants?partialName=<name> query with no take and scans only the first page (server default page size) for an exact match - it never paginates. partialName is a contains filter, so a tenant whose exact name sorts after a page's worth of other tenants containing the same substring (e.g. --tenant Smith in a space with Aaron Smith, Bob Smith, ... Zoe Smith) resolves to ErrItemNotFound and the deploy fails with cannot find a tenant with the ID or name of 'Smith' - even though that exact name worked before this PR, when it was passed straight through and matched server-side.
Since this can turn a working --tenant <name> into a hard failure on the deploy hot path, consider resolving names with a paginated exact-match lookup (the way the old selectors.FindEnvironment walked GetNextPage) rather than relying on GetByIdentifier's first-page fallback. (Projects.GetByIdentifier has the same latent flaw for --project, but this PR extends the exposure to --tenant on release deploy and runbook run.)
There was a problem hiding this comment.
Actioned in 93ab1c6.
Confirmed against the SDK we pin (go-octopusdeploy/v2 v2.114.1, pkg/tenants/tenant_service.go): GetByIdentifier (L234) falls back to GetByName (L213), which does a single s.Get(TenantsQuery{PartialName: name}) and then scans only tenants.Items — the first page — before returning services.ErrItemNotFound. No take, no GetNextPage. The finding holds exactly as described.
selectors.FindTenant no longer calls GetByIdentifier. It does the ID lookup itself (Tenants.GetByID, treating a 404 as "not an ID, try the name") and then walks every page of the partialName search looking for a case-insensitive exact match, keeping ID-beats-name precedence. The paging loop uses resultPage.GetNextPage(octopus.Tenants.GetClient()), which returns nil, nil when Links.PageNext is empty, so it terminates on the last page.
Covered by TestFindTenants/finds an exact name match beyond the first page of the partial name search in pkg/question/selectors/find_test.go, which serves Aaron Smith on page one with a PageNext link and the exact Smith on page two.
Residual, deliberately not fixed here: Projects.GetByIdentifier still has the same first-page-only name fallback, and selectors.FindProject still goes through it. That's pre-existing on main and this PR doesn't widen it, so I've left it — say the word if you want it pulled in.
PR #703 raises the same tenant-pagination finding; the conclusion there is the same, and the fix lives in selectors.FindTenant, which both branches share.
|
|
||
| ephemeralEnvironments, ephemeralErr := findEphemeralEnvironments(octopus, space, environmentIdentifiers) | ||
| if ephemeralErr != nil { | ||
| return nil, err // ephemeral environments are the rarer case; report why the regular lookup failed |
There was a problem hiding this comment.
Because the fallback is all-or-nothing over the whole list, a list mixing a regular and an ephemeral environment can never resolve: the regular lookup errors on the ephemeral name, the ephemeral lookup then errors on the regular name, and the user sees cannot find an environment with the ID or name of '<ephemeral env>' - blaming an environment that exists. Before this PR the --no-prompt path passed the names through and left the mix to the server.
If mixed lists are meant to be invalid that's defensible, but the message is misleading. Resolving per-identifier (regular list first, then ephemeral; each list fetched at most once) would keep single-type lists behaving exactly as now, while letting mixed lists either resolve or fail with an accurate message.
There was a problem hiding this comment.
Actioned in 3d8333b. The finding holds: the fallback was all-or-nothing over the whole list, so a mixed list could never resolve and the error named an environment that exists.
The resolver moved out of deploy.go into selectors.ResolveEnvironmentNames and now works per identifier: each one is matched against the regular environment list first, and the ephemeral list is consulted only for identifiers that list doesn't have — fetched once, lazily, so a single-type list makes exactly the same calls it did before. A genuine miss now names the identifier that actually went missing.
Two things that came out of the rework:
- it used to fall back on any error from the regular lookup, including a transport failure; now the regular lookup's error is returned as-is and only a miss triggers the ephemeral list.
findEphemeralEnvironmentsindexed name-then-ID, so a name could shadow another environment's ID. It now indexes ID-first, matching the precedence everywhere else.
Covered by TestResolveEnvironmentNames in pkg/question/selectors/find_test.go: resolves a mix of regular and ephemeral environments, doesn't look at ephemeral environments when everything resolves (asserts the ephemeral endpoint is never hit), and names the environment that is actually missing.
Deliberate residual: if the ephemeral endpoint itself errors (it doesn't exist on every server version) the error is swallowed and reported as cannot find an environment with the ID or name of '<identifier>', because the identifier genuinely isn't a regular environment and a "v2 environments endpoint returned 404" message would be worse for the common case. That does mean a real transport failure against that endpoint is reported as not-found.
PR #703 raises the same mixed-list finding; same conclusion, and the fix is in the shared selectors resolver so both branches pick it up.
|
|
||
| // the executions API only matches environments and tenants by name, so resolve any IDs we were given | ||
| if len(flags.Environments.Value) > 0 { | ||
| selectedEnvironments, err := selectors.FindEnvironments(octopus, flags.Environments.Value) |
There was a problem hiding this comment.
Question: release deploy got the ephemeral fallback (resolveEnvironmentNames) but the runbook path uses bare selectors.FindEnvironments, which only sees GET /environments/all - and ephemeral environments aren't in that list. Before this PR the value was passed straight through for the server to resolve, so if a runbook run can target an ephemeral environment by name, that now fails client-side with cannot find an environment with the ID or name of '...'.
If runbook runs can never target ephemeral environments this is fine as-is; otherwise this call probably wants the same fallback deploy got.
There was a problem hiding this comment.
Answering the question: oversight, not intentional. Actioned in 3d8333b (and tightened in 956f445).
runbook run now calls the same selectors.ResolveEnvironmentNames(octopus, f.GetCurrentSpace(), ...) that release deploy's no-prompt path uses, so an environment name that used to be passed through for the server to resolve is no longer rejected client-side just because it isn't in GET /environments/all.
956f445 closes the other half of it: the prompt path used to re-resolve the canonical name through executionscommon.FindEnvironments when picking run targets, which would have failed for an ephemeral environment even after the fallback was in place. That path now reuses the ID the up-front resolve produced, so an ephemeral name survives the whole flow.
Unverified: whether the runbook-run executions API actually accepts an ephemeral environment by name server-side. I have no instance to try it against. What I can say is that the CLI no longer decides the answer for the server — the behaviour is back to "pass it on and let the server rule", which is what it was before this branch. If you know runbook runs can't target ephemeral environments at all, the right change is a specific error message rather than the generic cannot find an environment..., and I'd want that from you rather than guessing.
|
|
||
| // the executions API only matches environments and tenants by name, so resolve any IDs we were given | ||
| if len(flags.Environments.Value) > 0 { | ||
| flags.Environments.Value, err = selectors.ResolveEnvironmentNames(octopus, f.GetCurrentSpace(), flags.Environments.Value) |
There was a problem hiding this comment.
Keep the resolved environment identity for later runbook lookups.
This replaces the original identifier with a name, but AskDbRunbookRunQuestions / AskGitRunbookRunQuestions later pass that name through the ID-first executionscommon.FindEnvironments again when selecting run targets. The by-tag path does the same for its prompted-variable preview.
Using the collision already covered by the selector tests: environment A has ID Environments-99 and name Environments-13; environment B has ID Environments-13 and name production. Passing --environment Environments-99 first resolves correctly to A, then the later lookup resolves its name to B. I reproduced that exact two-stage lookup: the final ID is Environments-13 instead of Environments-99. The run still submits A's name, but target selection and prompted-variable checks use B's preview.
Please retain the resolved environment objects/IDs for those lookups, or defer conversion to names until building the execution request. Add a runbook-level collision test covering the target or by-tag preview, rather than only testing each resolver independently.
There was a problem hiding this comment.
Actioned in 956f445. Reproduced your two-stage collision before fixing it, and the resolved identity is now carried through instead of the name being re-resolved.
What changed:
selectors.ResolveEnvironmentsreturns[]*ResolvedEnvironment{ID, Name}— the ID and name of whichever environment each identifier picks out, regular or ephemeral.ResolveEnvironmentNamesis now a thin wrapper over it for callers that only want names (release deploy's no-prompt path).runbookRunresolves once and threads the resolved slice throughrunDbRunbook/runGitRunbook/runRunbooksByTagand intoAskDbRunbookRunQuestions/AskGitRunbookRunQuestions, the same wayprojectis already threaded. Nothing on the command path resolves an environment twice.askRunbookTargets/askGitRunbookTargetsnow takeenvironmentIDs []string. They only ever usedenv.ID, and taking IDs makes it impossible to hand them a name to re-resolve.- the by-tag prompted-variable preview uses the resolved ID. It also used to call
FindEnvironmentsonce per matching runbook; it's now one lookup for the whole loop. - the
FindEnvironmentsname lookup survives only as a fallback for the exportedAsk*entry points, which can legitimately be called without pre-resolved environments.
Test, as asked, at the runbook level rather than per-resolver: TestRunbookRunByTag_UsesTheResolvedEnvironmentForThePreview in pkg/cmd/runbook/run/run_test.go. It uses your collision (Environments-99/Environments-13 vs Environments-13/production), runs runbook run --runbook-tag nightly --environment Environments-99 in automation mode, and asserts the preview goes to .../runbookRuns/preview/Environments-99 and that /environments/all is fetched exactly once. I checked it fails on the old shape — reinstating the FindEnvironments call makes the mock server report /api/Spaces-1/environments/all where it expected the Environments-99 preview. There's also TestResolveEnvironments in find_test.go pinning the ID/name pair for the colliding identifier.
Two residuals worth naming:
askRunbookPreviewVariablesstill receives the interactively-selected environments only, so it gets nothing when--environmentcame from the command line, and prompted variables aren't previewed on that path. That's pre-existing and unrelated to the double-resolution, so I left it rather than change interactive behaviour inside this PR. Happy to do it separately — it would mean users who pass--environmentinteractively start getting prompted-variable questions they don't get today, which is a behaviour change worth its own decision.executionscommon.AskTenantsAndTagslikewise still gets the interactive selection only, for the same reason.
release deploy doesn't have the equivalent bug: deployRun never pre-converts options.Environments to names, and AskQuestions resolves them exactly once (deploy.go L492-L500), so there's no second lookup to collide.
…enant The executions API only matches channels, environments and tenants by name, so `release create`, `release deploy` and `runbook run` passed whatever the caller typed straight through and the server rejected IDs. `--project` already worked because the server accepts a project ID or name. Resolve those identifiers client side through the shared selectors package before handing them to the executor, preferring an ID match over a name match so it behaves the same way as `--project`. Fixes #250 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`Tenants.GetByIdentifier`'s name fallback (`GetByName`) issues a single `tenants?partialName=<name>` query and scans only the first page of the result. `partialName` is a contains filter, so an exact name that sorts past a page's worth of other tenants containing the same substring - e.g. `--tenant Smith` in a space full of `... Smith` tenants - came back as `ErrItemNotFound` and failed the deploy, even though the same name worked before this branch, when it was passed through and matched server side. `selectors.FindTenant` now does the ID lookup itself and walks every page of the partial name search looking for an exact match, keeping the same ID-beats-name precedence. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…emeral fallback with runbook run The ephemeral fallback was all-or-nothing over the whole `--environment` list: a list mixing a regular and an ephemeral environment could never resolve, because the regular lookup errored on the ephemeral name and the ephemeral lookup then errored on the regular one, leaving the user with `cannot find an environment with the ID or name of '<ephemeral env>'` - blaming an environment that exists. It also fell back on *any* error from the regular lookup, including a transport failure. `selectors.ResolveEnvironmentNames` now resolves each identifier in turn against the regular environment list, consulting the ephemeral list only for identifiers that list doesn't have (fetched once, lazily). Single-type lists behave exactly as before; mixed lists resolve, and a genuine miss names the identifier that actually went missing. `runbook run` uses the same resolver, so an ephemeral environment name that used to be passed through to the server no longer fails client side. Also flips ephemeral name/ID indexing in `findEphemeralEnvironments` so an ID match wins a collision, matching the precedence everywhere else. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`runbook run` resolved `--environment` to canonical names up front, then handed those names back to the ID-first `executionscommon.FindEnvironments` when picking run targets and when previewing prompted variables for a by-tag run. With the collision the selector tests already cover - environment A is `Environments-99`/`Environments-13` and environment B is `Environments-13`/`production` - `--environment Environments-99` resolved to A, and the second lookup then resolved A's name to B. The run still submitted A's name, but target selection and the prompted-variable check used B's preview. `selectors.ResolveEnvironments` now returns the ID and name of each environment an identifier picks out (`ResolveEnvironmentNames` is a thin wrapper for callers that only want names), and `runbook run` threads that resolved identity down through `runDbRunbook`/`runGitRunbook`/ `runRunbooksByTag` and into the Ask* questions, so nothing resolves an environment twice. The run-target helpers now take environment IDs, since that's all they ever used. The by-tag preview also stops re-listing every environment once per matching runbook. The name lookup is kept as a fallback for the exported `Ask*` entry points, which can be called without pre-resolved environments. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The --priority tests arrived on main (#708) after this branch was cut, so they were the only deploy cases not already expecting the environments/all lookup this branch adds. Same one-line expectation as every other case here. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
956f445 to
de14d35
Compare
Fixes #250
The bug
The reporter's build tooling uses IDs everywhere (names change, IDs don't).
--projectaccepts an ID, but--channel,--environmentand--tenantdid not:Root cause: the executions API (
releases/create/v1,deployments/create/{un}tenanted/v1,runbook-runs/create/v1) takesprojectNameas an ID-or-name — the server resolves it — butchannelIDOrName,environmentName(s)andtenantsare matched by name only, server side. The CLI passed whatever the caller typed straight through, so IDs blew up.Name-vs-ID inventory (before this PR)
release create--projectselectors.FindProject→Projects.GetByIdentifier--channelselectors.FindChannel(interactive) / passed through (--no-prompt)--git-ref,--git-commit--package,--package-version,--git-resource--version,--release-notes,--ignore-*,--custom-fieldsrelease deploy--projectselectors.FindProject--environmentexecutionscommon.FindEnvironments(some paths) / passed through--tenant--tenant-tagRegions/us-east)--deployment-target/--exclude-deployment-targetSpecificMachineNames/ExcludedMachineNames--skip--deployment-freeze-nameDeploymentFreezeNames--version,--deploy-at,--variable, …Note on
--environment:executionscommon.FindEnvironmentsalready matched name-or-ID, but (a) only some code paths called it, (b) it never wrote the canonical name back into the options, so the raw ID still went to the server, and (c) the tenanted path usedselectors.FindEnvironment, which was name-only.runbook run--projectselectors.ResolveProject--snapshotrunbooks.GetSnapshot--name/--runbookrunbooks.GetByName--runbook-tag--environment--tenant--tenant-tag--run-target/--exclude-run-target--skipWhat changed
Everything goes through the shared
selectorspackage, so every caller benefits:selectors.FindEnvironments/FindEnvironment(pkg/question/selectors/environments.go) — the one environment resolver. OneGET /environments/all, matched client side by ID first, then name.executionscommon.FindEnvironmentsnow delegates to it, so its ~12 existing callers (target create commands,tenant connect) pick up the consistent precedence for free.selectors.FindChannel— now matches a channel's ID as well as its name, still scoped to the project so an ID from another project is never returned.selectors.FindTenant/FindTenants(newpkg/question/selectors/tenants.go) — wrapsTenants.GetByIdentifier(ID first, then name), turning "not found" into a clear message.release create— resolves--channelin the--no-promptpath and sends the canonical name.release deploy— resolves--tenantfor both paths and--environmentin the--no-promptpath; the interactive paths now write the resolved canonical name back into the options so what's sent (and what appears in the generated automation command) is the real name, not the raw input.runbook run— resolves--environmentand--tenantonce, right after the project is resolved, covering the db/git/by-tag and prompt/no-prompt paths.release deployfalls back to the ephemeral lookup when the regular one comes up empty (resolveEnvironmentNames).Precedence rule
Try an exact ID match first, fall back to an exact name match (case-insensitive).
This matches what
--projectalready does (Projects.GetByIdentifier=GetByID, thenGetByName) and whatchannel/shared.ResolveChannelalready did. So an entity that is genuinely namedEnvironments-201loses to the entity whose ID isEnvironments-201. It is documented in the resolver comments and covered by tests ("prefers an ID match over a name match").Note this flips the old precedence inside
executionscommon.FindEnvironments, which tried name first. That only matters when a name collides with a different entity's ID, and consistency with--projectseemed more valuable than preserving the old order.Error messages
Uniform and explicit about both forms being accepted:
cannot find an environment with the ID or name of 'Environments-404'cannot find a channel in project 'Fire Project' with the ID or name of 'Channels-404'cannot find a tenant with the ID or name of 'Tenants-404'The channel message previously read
no channel found with name of X; the two channel test files touched (channel/delete,channel/view) only update that expected string — no behaviour change to those commands.Test evidence
New tests:
pkg/question/selectors/find_test.go— table tests forFindEnvironments/FindEnvironment/FindChannel/FindTenants: by name, by name ignoring case, by ID, several at once, ID beats a colliding name, and the not-found error text.release create— "release creation specifying the project and channel by ID": asserts the POST body carriesChannelIDOrName: "BetaChannel"when--channel Channels-31was passed.release deploy— "release deploy specifying project, environment and tenant by ID": assertsEnvironmentName: "dev"/Tenants: ["Coke"]when--environment Environments-12 --tenant Tenants-29was passed.runbook run— "runbook run specifying project, environment and tenant by ID": same assertion forrunbook-runs/create/v1.Existing tests updated for the extra lookup requests (and one deploy test now asserts
"Ephemeral Environment"instead of the lowercase"ephemeral environment"the user typed — that normalisation is the fix working).go vet ./...reports only two pre-existingunreachable codewarnings inpkg/cmd/tenant/variables/list/list.go, untouched here.Open questions / options
Precedence: ID first, or name first? Chosen: ID first, for consistency with
--project. The alternative — name first — would mean an environment namedEnvironments-201shadows the environment with that ID, which is arguably the more surprising outcome and is inconsistent with--project. A third option is to error on ambiguity, which is the most correct but adds a failure mode to a hot path for a collision nobody has ever reported. Recommendation: keep ID first.Extra round trip.
runbook run --environment Xandrelease deploy --no-prompt --environment Xnow always do oneGET /environments/all(and one or two calls per--tenant) that they previously skipped. That's the price of resolving client side. The alternative is a "looks like an ID" regex (^Environments-\d+$) to skip the lookup for plain names — faster, but it bakes in an ID-format assumption and makes the collision case incoherent. Recommendation: keep the unconditional lookup; revisit only if someone measures a problem.executionscommon.FindEnvironmentsis now a one-line alias forselectors.FindEnvironments. I kept it so the ~12 callers in the target-create commands andtenant connectstay untouched and this PR's diff stays on-topic. Recommendation: delete it and update the call sites in a follow-up, in line with the recentselectors.ResolveProject/selectors.Channelconsolidation — happy to fold that in here instead if reviewers prefer.Flags deliberately left alone — all name-only, all out of scope, none reported:
--deployment-target/--exclude-deployment-target/--run-target/--exclude-run-target(machine names),--skip(step names),--deployment-freeze-name,--name/--runbook(runbook name), and the package/git-resource step specs. Machines and runbooks would be the natural next candidates if we want ID support everywhere; the server-side commands for those take names only, so each needs the same client-side resolve-then-send treatment. Recommendation: follow-up issue rather than growing this PR.--tenant-taghas no ID form at all — canonical tag paths are the only identifier.Should
release create --channelresolve in interactive mode too? It already did (viaselectors.FindChannel), and now picks up ID support for free. Worth a reviewer sanity check that echoing the resolved name back (rather than the ID the user typed) is the desired UX. I think it is — it confirms what the ID pointed at.🤖 Generated with Claude Code